Python SDK里两个隐蔽的生命周期Bug

前言

最近 SDK 连续遇到两个很隐蔽的问题:

  1. 链式调用 ServiceClient().create_xxx_client() 后,临时的根 Client 被析构,顺手关闭了派生 Client 还在使用的远程 Session。
  2. 启动遥测后台线程时,如果 Python 线程 bootstrap 异常,ServiceClient 初始化会永久卡住。

一个是对象所有权,一个是线程启动协议。表面症状完全不同,根因却很接近:资源生命周期被绑定在了一个不可靠的局部对象或隐式状态上。

问题一:临时对象析构关闭了还在用的Session

用户很自然会写:

1
training_client = ServiceClient().create_lora_training_client(...)

表达式执行完后,临时 ServiceClient 没有任何变量引用,在 CPython 中很快会因为引用计数归零而析构。

旧实现把远程 ServiceSession 的 finalizer 绑在根 Client 上,于是发生:

1
2
3
4
5
6
7
创建临时ServiceClient
→ 创建远程ServiceSession
→ 派生TrainingClient
→ 表达式结束
→ 临时ServiceClient析构
→ finalizer关闭ServiceSession
→ TrainingClient下一次请求得到409 conflict

用户手里明明还有一个 TrainingClient,服务端资源却已经被另一个临时对象关掉了。

谁才应该拥有远程Session

派生 Client、心跳、HTTP client、异步事件循环都依赖同一个 ServiceSession,因此所有权不应该属于根 ServiceClient 外壳,而应该属于它们共享的 Runtime。

修复后的关系是:

1
2
3
ServiceClient ─┐
TrainingClient ├─→ ServiceRuntime → ServiceSession owner
SamplingClient ┘

临时根 Client 析构时,只释放自己对 Runtime 的引用。最后一个派生 Client 释放、或用户显式 shutdown 时,Runtime 才按顺序关闭:

1
2
3
4
停止heartbeat
→ 关闭远程ServiceSession
→ 回收Actor/Telemetry/Control client
→ 停止异步线程和executor

Session owner 需要线程安全且幂等,因为显式 close、context manager、finalizer 和异常回滚都可能尝试关闭同一个资源。

这里的原则是:依赖资源的对象共享谁,谁就拥有资源;不要让一个方便创建它的临时 facade 决定资源寿命。

问题二:threading.Thread.start也可能永久等待

另一个问题表现为:

1
ServiceClient().get_supported_models()

什么网络请求都还没发,程序就卡死。

最小复现通过注入线程 bootstrap 失败稳定触发:

1
2
3
threading.Thread._bootstrap_inner = lambda self: (
_ for _ in ()
).throw(RuntimeError("injected bootstrap failure"))

容易误以为只是主线程等待某个 ready Event,而 worker 失败后没通知。继续看 Python 实现才发现,threading.Thread.start() 自己也会等待内部 _started Event。

如果线程已经被底层创建,但在设置 _started 之前 bootstrap 异常,调用 start() 的主线程没有公开超时参数,可以永久阻塞。

这意味着在 Thread.start() 外面再包一层业务 ready timeout 也救不了,因为程序根本还没有从 start() 返回。

有界启动协议

修复没有继续依赖 threading.Thread.start() 的内部握手,而是使用更低层的 _thread 派发 worker,再由 SDK 自己管理有界启动协议。

Telemetry 和业务 AsyncHolder 的策略不同。

遥测是非关键能力,采用 fail-open:

1
2
3
4
最多等待1秒
派发失败 / 初始化失败 / 超时
→ 降级为no-op telemetry
→ SDK和业务请求继续

业务异步 Runtime 是关键资源,使用 5 秒总启动窗口:

1
2
3
4
底层派发立即失败
→ 50ms退避重试
→ 200ms退避重试
→ 仍失败则构造失败并回滚

一旦某个 worker 已经成功派发,就只等待这个 worker,不会因为 ready 较慢重复创建线程。超时后才获得调度的迟到 worker 会看到废弃状态并自行退出。

Runtime构造也要像事务

ServiceRuntime 初始化会依次创建 HTTP client、executor、Telemetry、Control client 和 AsyncHolder。

如果中途失败,简单抛 exception 会泄漏前面已经创建的资源。修复后构造过程按所有权反向回滚:

1
2
3
4
5
6
创建A
→ 创建B
→ 创建C失败
→ close B
→ close A
→ 抛出初始化错误

这和数据库事务的思路一样,只不过回滚对象换成了线程、连接池和远程会话。

如何测试这种低概率问题

等待线上偶发线程调度异常几乎不可行,必须做故障注入:

  • bootstrap 在设置 started 前抛异常
  • worker 永不 ready
  • worker 超过 deadline 才开始运行
  • 事件循环初始化失败
  • 第一次派发失败、第二次恢复
  • worker 在自己的线程中触发关闭
  • Runtime 后半段构造失败,前半段资源是否回收

最终原始 bootstrap 注入下,ServiceClient 初始化和模型目录请求在 0.08 秒内完成;遥测 endpoint 断连时,业务请求仍能继续。

最后

Python 的垃圾回收和 threading.Thread 都很成熟,但成熟抽象也有自己的生命周期假设。

派生对象比创建者活得更久时,资源所有权必须上移到共享 Runtime;后台能力可能失败时,启动等待必须有上限;部分构造失败时,还要回滚已经拿到的资源。

所以说在实现自己的对象的时候一定要注意生命周期的依赖关系问题

相关实现:

作者

Noah Shen

发布于

2026-08-06

更新于

2026-08-06

许可协议

评论